Problem jaki będę się starał rozwiązać, to sprawdzene czy jesteśmy w stanie w jednoznaczny sposób zgrupować filmy na youtube bazując na ich opisach i sprawdzić w jakim stopniu grupy te pokrywają się z zapytaniem jakie zostało użyte do wyszukania tych filmów. Posłużę się różnymi metodami czyszczenia, wektoryzacji i grupowania danych. Postaram się w jasny sposób opisać moją pracę w niniejszym raporcie.
import pandas as pd
import numpy as np
from typing import List, Set, Callable
from matplotlib import pyplot as plt
import itertools
from sklearn.decomposition import PCA, KernelPCA, TruncatedSVD
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer, HashingVectorizer
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics.cluster import homogeneity_score, silhouette_score
from sklearn.cluster import KMeans, DBSCAN
from data_cleaning import DataCleaner
from analysis import clusters_overlapping, mean_distance_from_centroid
from plotly_utils import build_2d_figure, build_3d_figure, print_correlation_heatmap
import plotly.graph_objects as go
from feature_extraction import Doc2VecVectorizer
from scipy.spatial.distance import euclidean, mahalanobis, cosine
Zbiór danych którymi będę się posługiwał pochodzi ze strony youtube.com. Są to dane tekstowe opisujące film, takie jak tytuł, opis filmu i zapytanie jakie zostało użyte aby ten film znaleźć. Dane zostały pobrane przy użyciu API developerskiego. Scrapper do danych znajduje się w projekcie pythonowym (scrapper.py) dołączonym do tego raportu.
df = pd.read_csv(
"dataset_huge.csv",
usecols=["id", "title", "search_query", "description"]
).dropna().reset_index()
print(df["search_query"].unique().tolist())
Niektóre z zapytań są powiązane, inne różne, celem było stworzenie różnorodnego zbioru.
Dane filmowe mają równomierny rozkład, każde z zapytań zawiera około 50 filmów, pojedyńcze zapytania zostały wstępnie usunięte ze względu na brak niektórych informacji.
df["search_query"].value_counts().plot(kind="bar", title='Query distribution', figsize=(16, 4))
Głównym elementem z którym będę pracował w tym raporcie będą opisy pod filmami. Zawierają one najwięcej informacji tekstowych i mam nadzieję że dadzą najciekawsze wyniki podczas dalszych analiz.
Sprawdżmy zatem jaki jest rozkład ilości słów w nie przetworzonych opisach filmów z pobranym zbiorze danych.
df["description"].apply(
lambda words: int(len(words.split()) / 20) * 20
).value_counts().sort_index().plot(kind="bar", figsize=(16, 4))
plt.title('Ilość słów w opisach filmów')
plt.xlabel('Ilość słów')
plt.ylabel('Ilość słów w opisach filmów')
df["description"].apply(
lambda words: int(len(words.split()) / 20) * 20
).value_counts().sort_index().cumsum().plot(kind="bar", figsize=(16, 4))
plt.title('Ilość słów w opisach filmów')
plt.xlabel('Ilość słów')
plt.ylabel('Ilość słów w opisach filmów')
W zbiorze dominują krótkie opisy. Ponad 75% opisów posiada tekst krótszy niż 240 słów.
def build_dictionary(data: pd.Series) -> Set[str]:
return np.unique(pd.Series(itertools.chain.from_iterable(data.str.split())))
print("Słownik zawiera: {} unikalnych słów.".format(len(build_dictionary(df["description"]))))
Oczyszczanie tekstu jest procesem złożonym, składającym się najczęściej z kilku kroków. Zostają one dobrane dla indywidualnych porzeb analiz i opierają się na konkretnym zbiorze danych. Proces czyszczenia tekstu wykorzystany w tym raporcie zawiera kroki:
Implementacja zawiera się w module data_cleaning.py dołączonym do tego raportu.
cleaner = DataCleaner()
df["cleaned_description"] = df["description"].apply(cleaner.clean_description)
print("Po czyszczeniu słownik zawiera: {} unikalnych słów.".format(
len(build_dictionary(df["cleaned_description"]))
))
Raport zawiera implementację 4 możliwych wektoryzacji danych. W wersji uruchomieniowej mozna zastosować każdą z nich. Wystarczy odkomentować linijkę kodu. Użyte metody wektoryzacji:
Raport zawiera implementację dwóch sposobów redukcji cech. W wersji uruchomieniowej mozna zastosować każdą z nich. Wystarczy odkomentować linijkę kodu. Użyte metody redukcji cech:
# vectorizer = HashingVectorizer(ngram_range=(1, 3), n_features=1000)
# vectorizer = CountVectorizer(ngram_range=(1, 3))
# vectorizer = TfidfVectorizer(ngram_range=(1, 3))
vectorizer = Doc2VecVectorizer(vector_size=400)
dimension_reductor = TruncatedSVD(n_components=3)
# dimension_reductor = PCA(n_components=3)
embeddings = vectorizer.fit_transform(df["cleaned_description"])#.todense()
# normalization over columns (subtract min and divide by peak-to-peak method - guarantee [0, 1] values)
# embeddings = (embeddings - embeddings.min(axis=0)) / embeddings.ptp(axis=0)
## embeddings truncation
truncated_embeddings = dimension_reductor.fit_transform(embeddings)
# labels encodding for colors purpose
encoded_labels = LabelEncoder().fit_transform(df["search_query"])
df["encoded_labels"] = encoded_labels
Do grupowania zostaną użyte dwa algorytmy. Kmeans (K-średnich) i Aglomerative clustering (grupowanie aglomeracyjne).
Algorytm K-średnich to najpopularniejszy algorytm grupowania. Polega na wybraniu k centroidów i sprawdzaniu odległości pomiędzy obiektami i najbliższym centroidem. Obiekty przypisywane są klasie najbliższego centroidu. Pozycje centroidów są aktualizowane najczęściej na podstawie średniej arytmetycznej z obiektów przypisanych do klasy danego centroidu. Po aktualizacji pozycji centroidu algorytm wykonywany jest ponownie, iterujemy tak do momentu braku potrzeby aktualizacji centroidu.
Density-Based Spatial Clustering of Applications with Noise - algorytm grupowania bazujący na gęstości obserwacji. Znajduje na początku obserwacje bazowe i na podstawie gęstości wyszukuje najbliższe obserwacje i grupuje je w klasy.
# same clusters number as original dataset
clusters_number = df["search_query"].unique().shape[0]
df["predicted_classes"] = KMeans(n_clusters=clusters_number).fit_predict(embeddings)
# df["predicted_classes"] = DBSCAN(eps=0.5, min_samples=2, metric='euclidean').fit(embeddings).labels_
# df["predicted_classes"] = DBSCAN(eps=0.5, min_samples=2, metric='cityblock').fit(embeddings).labels_
# df["predicted_classes"] = DBSCAN(eps=0.5, min_samples=2, metric='cosine').fit(embeddings).labels_
# df["predicted_classes"] = DBSCAN(eps=0.5, min_samples=2, metric='manhattan').fit(embeddings).labels_
Celem badań jest sprawdzenie w jak dużym stopniu klastry wykryte przez algorytm pokrywają się z rzeczywistymi klastrami wygenerowanymi przez zapytania do youtube. Będę badać rozpiętość klastrów w przestrzeni cech i rozłożenie ich elementów. Zostaną wykorzystane też metryki dedykowane do walidacji jakości klastrowania na konkretnych danych.
df["predicted_classes"].value_counts().sort_index().plot(
kind="bar", figsize=(16, 4), title="Ilość obiektów w poszczególnych klastrach"
)
Diagram przedstawia ilość obserwacji znajdujących się w obu klasach.
print_correlation_heatmap(
clusters_overlapping(df["search_query"], df["predicted_classes"])
)
Wiele klas jest skorelowanych z jedną konkretną klasą utworzoną algorytmem grupowania. Można domniemywać iż są to klasy odpowiadające sobie. Maksymalna ilość obserwacji pokrywających się jest równa 11 co jest 1/5 wielkości rzeczywistego klastra.
Badanie to pozwoli nam sprawdzić jak mocno skupione są klasy wokół ich centroidów, im mniejszy wynik tym grupa obserwacji jest bardziej skupiona.
mean_distance_from_centroid(euclidean, df["predicted_classes"], embeddings).sort_values("label").plot(
kind="bar", x="label", y="mean distance", figsize=(16,5))
Zbadanie średnich odległości obserwacji od centroidów klas pozwala nam stwierdzić jak mocno klasy rozłożone są w przestrzeni cech.
Jest to algorytm którego wynik pozwala stwierdzić czy w odpowiedni sposób została dobrana ilość klastrów do danych. Działanie tego algorytmu oparte jest na dwóch wielkościach a - odległość obserwacji od centroidu jej klasy i b czyli odległość do najbliższego centroidu klasy do której obserwacja nie należy. Wzór na współczynnik silhouette to : (b - a) / max(a, b). Wartość wspólczynnika mieści się w granich [-1, 1] gdzie 1 to wynik najlepszy, -1 to wynik najgorszy.
Wartość współczynnika dla klas rzeczywistych:
silhouette_score(embeddings, labels=df["search_query"])
Wartość jest ujemna, znaczy to że grupowanie po klasie rzeczywistej nie jest zbyt dobre. Obserwacje przypisane do poszczególnych klas są bardzo mocno rozrzucone w przestrzeni cech, grupy nie są spójne i separowalne w prosty sposób. Wynik metryki dla ilości klas odpowiadającej ilości rzeczywistej przewidzianych klas:
silhouette_score(embeddings, labels=df["predicted_classes"])
Wynik jest o wiele lepszy niż ten osiągnięty dla klas rzeczywistych. Prawdopodobnie zmiana ilości klas jeszcze bardziej poprawiła by wynik.
build_2d_figure(df, df["encoded_labels"], truncated_embeddings).show()
build_2d_figure(df, df["predicted_classes"], truncated_embeddings).show()
build_3d_figure(df, df["encoded_labels"], truncated_embeddings).show()
build_3d_figure(df, df["predicted_classes"], truncated_embeddings).show()
Zbiór danych jest reprezentowany przez 1000 obserwacji rozłożonych na 20 klas. Reprezentacja poszczególnych klas wynosi 50 obserwacji. Prawdopodobnie nie jest to wystarczająca ilościowa reprezentacja poszczególnych klas dla tak złożonych danych jakimi są opisy tekstowe. Prawdopodobnie zwiększenie reprezentacji o rząd wielkości dla każdej klasy, tzn. 500 obserwacji, bardzo mocno poprawiła by wyniki grupowania.
W przypadku rozszerzenia zbioru danych byś może należało by zastosować inne narzędzia do ich przetwarzania. Dla bardzo dużych wolumenów danych zasadnym staje się użycie technologii chmurowych np. Google Cloud Platform (GCP). Chmura google zawiera dedykowane rozwiązania do zarządzania dużymi wolumenami danych takie jak np. BigQuery bądź nawet BigTable (jeśli wolumen danych przekroczy 2TB). Rozwiązanie takie wymagało by poważnego zmodyfikowana przedstawionego tutaj algorytmu, jednakże jest możliwe.
Przy rozwoju aplikacji można by było zastosować bardziej złożone sposobu wektoryzacji obserwacji np. wykorzystanie najnowszych rozwiązań w NLP jakim jest Transformer BERT. Transfer wiedzy z modelu przeuczonego na słowniku dla języka angielskiego, prawdopodobnie dała by bardziej reprezentatywne wektory obserwacji.
Cel podstawiony na początku tego opracowania udało się osiągnąć. Jednak rezultat nie jest jednoznacznie dobry. Udało się stwiedzić że grupy utowrzone przy pomocy algorytmów grupowanie są znacząco rózne od etykiet które zostały przypisane w momencie wyszukiwania filmów. Pewne klasy w danych są spójne, ale w większości klasy są reprezentowane w dużej części przestrzeni cech i nie można jednoznacznie stwierdzić przygotować grupowania które było by spólne z rzeczywistym przypisaniem do klas.
Być może zwiększenie wolumenu danych i zastosowanie bardziej zaawansowanych metod wektoryzacji pozwoliły by osiągnąć lepsze wyniki.